Skip to content

Cache postal code → ward lookups instead of re-resolving each one - #64

Open
mikaalnaik wants to merge 1 commit into
mainfrom
mikaal/ward-lookup-perf
Open

Cache postal code → ward lookups instead of re-resolving each one#64
mikaalnaik wants to merge 1 commit into
mainfrom
mikaal/ward-lookup-perf

Conversation

@mikaalnaik

Copy link
Copy Markdown
Contributor

The ward lookup on /toronto/vote was slow because nothing was cached anywhere. york_factory answers every lookup Cache-Control: no-store and our proxy forwarded that verbatim — so an answer that depends only on the postal code, and changes at most once per boundary import, was recomputed on every submit. The route's comment claimed the TTL "varies by outcome on purpose," but upstream doesn't do that today.

What changed

src/app/api/elections/ward-lookup/route.ts — the proxy now decides caching rather than forwarding it:

  • next: { revalidate } on the upstream fetch, so the second visitor to type a code never reaches york_factory
  • Cache-Control by outcome — a year for malformed input (a pure function of the string), a day for a resolved ward, an hour for an unknown code (a real one may land in a later import), no-store for a boundary data outage, which is a state to retry rather than an answer to keep
  • one cache key per code: m4c1s9, M4C-1S9 and M4C 1S9 collapse to the same entry. Anything that isn't a full postal code still goes upstream as typed, so york_factory keeps distinguishing "not a postal code" from "not in our data"

src/components/elections/WardLookup.tsx — fires as soon as the field holds a complete postal code (200ms debounce), so the answer is usually on screen before the visitor reaches the button. Results are held for the session, so correcting a typo back to a code already tried costs nothing, and a request-sequence guard keeps a slow earlier reply from overwriting a later one.

Testing

  • Against the real upstream, a repeat lookup went 752ms → 3ms
  • Against a stub: all three spellings of one code produced upstream_hits=1, a genuinely new code incremented it, and resolved carried s-maxage=86400. The stub was necessary because production york_factory can't currently return resolved — see below
  • tsc --noEmit and eslint clean

Two things reviewers should know

The lookup is currently broken upstream, independently of this PR. Production york_factory returns boundary_data_unavailable for every real Toronto postal code, so the box shows "We can't look that up right now." This PR makes it fast; it can't make it answer. The ward boundary geometries need loading in the warehouse.

Cloudflare won't honour s-maxage on an API path without a Cache Rule. The Next data cache layer works regardless, so the win holds either way, but a Cache Rule on /api/elections/ward-lookup would push these to the edge and make the first lookup fast too.

🤖 Generated with Claude Code

Every ward lookup was a full two-hop origin round trip. york_factory
answers each one `Cache-Control: no-store` and the proxy forwarded that
verbatim, so an answer that depends only on the postal code — and changes
at most once per boundary import — was recomputed for every submit,
including one a visitor had just tried.

The proxy now decides caching rather than forwarding it:

- Next's data cache on the upstream fetch, so the second visitor to type
  a code never reaches york_factory.
- Cache-Control by outcome: a year for malformed input (a pure function
  of the string), a day for a resolved ward, an hour for an unknown code
  (a real one may land in a later import), and no-store for a boundary
  data outage — a state to retry, not an answer to keep.
- One cache key per code: "m4c1s9", "M4C-1S9" and "M4C 1S9" collapse to
  the same entry. Anything that isn't a full postal code still goes
  upstream as typed, so york_factory keeps distinguishing "not a postal
  code" from "not in our data".

Client-side, the lookup fires as soon as the field holds a complete
postal code, so the answer is usually on screen before the visitor
reaches the button, and results are held for the session so correcting a
typo back to a code already tried costs nothing. A request-sequence
guard keeps a slow earlier reply from overwriting a later one.

Measured against the real upstream, a repeat lookup goes from 752ms to
3ms. Verified against a stub that all three spellings of one code
produce a single upstream hit, since production york_factory currently
returns boundary_data_unavailable for every Toronto code and can't
exercise the resolved path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Aug 19, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds normalized, outcome-sensitive caching to the ward-lookup proxy and debounced session caching to the Toronto ward lookup UI.

  • Canonicalizes full postal codes before forwarding them to york_factory.
  • Adds server-fetch and response cache policies based on lookup outcomes.
  • Automatically looks up complete postal codes and guards network responses by request sequence.
  • The client cache currently retains transient outage responses and does not invalidate older requests on cache hits.

Confidence Score: 3/5

The PR should not merge until cached transient outages remain retryable and cache hits correctly invalidate older in-flight lookups.

The new client cache can preserve boundary-data outages for an entire session, and its early-return path allows older requests to replace the result for the postal code currently shown in the input.

Files Needing Attention: src/components/elections/WardLookup.tsx

Important Files Changed

Filename Overview
src/app/api/elections/ward-lookup/route.ts Adds postal-code canonicalization and layered caching with response headers selected from the upstream outcome.
src/components/elections/WardLookup.tsx Adds debounced automatic lookup and session caching, but transient outage results persist and cached lookups fail to invalidate older requests.

Sequence Diagram

sequenceDiagram
    participant U as Visitor
    participant C as WardLookup UI
    participant P as Proxy route
    participant Y as york_factory
    U->>C: Enter complete postal code
    C->>C: Check session cache
    alt Cache miss
        C->>P: GET ward lookup
        P->>Y: Normalized lookup with revalidation
        Y-->>P: Outcome
        P-->>C: Outcome-specific Cache-Control
        C->>C: Store response in session cache
    else Cache hit
        C->>C: Display cached response
    end
Loading

Fix all with Greploop Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
src/components/elections/WardLookup.tsx:72-73
**Transient outages persist in session**

When the API returns `boundary_data_unavailable`, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.

```suggestion
      const result: WardLookupResponse = await res.json();
      if (result.reason !== "boundary_data_unavailable") {
        cache.set(normalize(typed) ?? typed, result);
      }
```

### Issue 2
src/components/elections/WardLookup.tsx:57-63
**Cache hits bypass sequence guard**

When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing `latest.current`; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.

```suggestion
    const request = ++latest.current;
    const cached = cache.get(normalize(typed) ?? typed);
    if (cached) {
      setState({ status: "done", result: cached });
      return;
    }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "Cache postal code → ward lookups instead..." | Re-trigger Greptile

Comment on lines +72 to +73
const result: WardLookupResponse = await res.json();
cache.set(normalize(typed) ?? typed, result);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Transient outages persist in session

When the API returns boundary_data_unavailable, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.

Suggested change
const result: WardLookupResponse = await res.json();
cache.set(normalize(typed) ?? typed, result);
const result: WardLookupResponse = await res.json();
if (result.reason !== "boundary_data_unavailable") {
cache.set(normalize(typed) ?? typed, result);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 72-73

Comment:
**Transient outages persist in session**

When the API returns `boundary_data_unavailable`, the unconditional cache write stores that transient outage in the module-scoped map, causing every retry for the postal code to keep showing "We can't look that up right now" for the remainder of the session even after the service recovers.

```suggestion
      const result: WardLookupResponse = await res.json();
      if (result.reason !== "boundary_data_unavailable") {
        cache.set(normalize(typed) ?? typed, result);
      }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

Comment on lines +57 to +63
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}

const request = ++latest.current;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Cache hits bypass sequence guard

When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing latest.current; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.

Suggested change
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
const request = ++latest.current;
const request = ++latest.current;
const cached = cache.get(normalize(typed) ?? typed);
if (cached) {
setState({ status: "done", result: cached });
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/components/elections/WardLookup.tsx
Line: 57-63

Comment:
**Cache hits bypass sequence guard**

When an uncached lookup remains in flight and the visitor enters a cached postal code, the cached branch returns without advancing `latest.current`; the earlier request therefore still passes the sequence guard and overwrites the current result with another postal code's ward or an error.

```suggestion
    const request = ++latest.current;
    const cached = cache.get(normalize(typed) ?? typed);
    if (cached) {
      setState({ status: "done", result: cached });
      return;
    }
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant